JavaScript 56.7%
TypeScript 42.6%
1"use client";23import { useEffect, useState, useCallback } from "react";4import { useParams } from "next/navigation";5import { motion } from "framer-motion";6import {7 Download, Lock, AlertCircle, Loader2, FileText, Image as ImageIcon, Film, Music, FileCode, File as FileIcon,8 ZoomIn, ZoomOut, Clock, Folder, ChevronRight, Home, Eye, FileSpreadsheet, Archive,9} from "lucide-react";10import Button from "@/components/ui/Button";11import Logo from "@/components/ui/Logo";12import ThemeToggle from "@/components/theme/ThemeToggle";1314interface FileInfo { id: string; name: string; mimeType: string; size: number; mode: string; expiresAt: string | null }15interface FolderListing {16 id: string; name: string; mode: string; expiresAt: string | null;17 folder: { id: string; name: string };18 crumbs: { id: string; name: string }[];19 folders: { id: string; name: string; files: number; children: number }[];20 files: { id: string; name: string; mimeType: string; size: number; updatedAt: string }[];21}2223function formatSize(bytes: number): string {24 if (!bytes) return "0 o";25 const k = 1024;26 const sizes = ["o", "Ko", "Mo", "Go", "To"];27 const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));28 return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];29}3031function formatExpiry(expiresAt: string | null): string | null {32 if (!expiresAt) return null;33 const diff = new Date(expiresAt).getTime() - Date.now();34 if (diff <= 0) return "Expiré";35 const hours = Math.floor(diff / 3600000);36 const days = Math.floor(hours / 24);37 if (days > 0) return `Expire dans ${days} j`;38 if (hours > 0) return `Expire dans ${hours} h`;39 return `Expire dans ${Math.floor(diff / 60000)} min`;40}4142type Category = "image" | "pdf" | "video" | "audio" | "text" | "spreadsheet" | "archive" | "other";43function getFileCategory(mimeType: string): Category {44 if (mimeType.startsWith("image/")) return "image";45 if (mimeType === "application/pdf") return "pdf";46 if (mimeType.startsWith("video/")) return "video";47 if (mimeType.startsWith("audio/")) return "audio";48 if (mimeType.includes("spreadsheet") || mimeType === "text/csv" || mimeType.includes("excel")) return "spreadsheet";49 if (mimeType.includes("zip") || mimeType.includes("compressed") || mimeType.includes("tar")) return "archive";50 if (mimeType.startsWith("text/") || /json|xml|javascript|yaml|x-sh|markdown/.test(mimeType)) return "text";51 return "other";52}5354function CategoryIcon({ category, className = "w-12 h-12" }: { category: Category; className?: string }) {55 switch (category) {56 case "image": return <ImageIcon className={`${className} text-emerald-500`} />;57 case "pdf": return <FileText className={`${className} text-rose-500`} />;58 case "video": return <Film className={`${className} text-purple-500`} />;59 case "audio": return <Music className={`${className} text-pink-500`} />;60 case "text": return <FileCode className={`${className} text-amber-500`} />;61 case "spreadsheet": return <FileSpreadsheet className={`${className} text-green-600`} />;62 case "archive": return <Archive className={`${className} text-orange-500`} />;63 default: return <FileIcon className={`${className} text-gray-400`} />;64 }65}6667export default function SharedPage() {68 const params = useParams();69 const token = params.token as string;7071 const [state, setState] = useState<"loading" | "password" | "preview" | "download" | "folder" | "error">("loading");72 const [error, setError] = useState("");73 const [errorType, setErrorType] = useState<"expired" | "invalid" | "network">("invalid");74 const [fileInfo, setFileInfo] = useState<FileInfo | null>(null);75 const [listing, setListing] = useState<FolderListing | null>(null);76 const [protectedName, setProtectedName] = useState("");77 const [protectedType, setProtectedType] = useState<"file" | "folder">("file");78 const [password, setPassword] = useState("");79 const [passwordLoading, setPasswordLoading] = useState(false);80 const [textContent, setTextContent] = useState<string | null>(null);81 const [imageZoom, setImageZoom] = useState(1);82 const [currentFolder, setCurrentFolder] = useState<string | null>(null);83 const [folderLoading, setFolderLoading] = useState(false);8485 const base = `/api/shared/${token}`;86 const fileContentUrl = (download?: boolean, fileId?: string) =>87 `${base}?content=true${fileId ? `&fileId=${fileId}` : ""}${download ? "&action=download" : ""}`;88 const zipUrl = (folderId?: string | null) => `${base}?action=download${folderId ? `&folderId=${folderId}` : ""}`;8990 /** Charge l'état du partage (le cookie posé après vérification du mot de passe suffit). */91 const load = useCallback(async (folderId?: string | null) => {92 try {93 const res = await fetch(`${base}${folderId ? `?folderId=${folderId}` : ""}`, { cache: "no-store" });94 if (!res.ok) {95 const data = await res.json().catch(() => ({}));96 setErrorType(res.status === 410 ? "expired" : "invalid");97 setError(data.error || (res.status === 410 ? "Ce lien a expiré" : "Lien invalide"));98 setState("error");99 return;100 }101 const data = await res.json();102 if (data.requiresPassword) {103 setProtectedName(data.name || data.fileName || "");104 setProtectedType(data.type === "folder" ? "folder" : "file");105 setState("password");106 return;107 }108 if (data.type === "folder") {109 setListing(data);110 setCurrentFolder(data.folder.id);111 setState("folder");112 return;113 }114 setFileInfo(data);115 setState(data.mode === "DOWNLOAD" ? "download" : "preview");116 } catch {117 setErrorType("network");118 setError("Erreur réseau");119 setState("error");120 }121 }, [base]);122123 useEffect(() => { load(); }, [load]);124125 // Texte : chargement du contenu pour l'aperçu126 useEffect(() => {127 if (!fileInfo || state !== "preview" || getFileCategory(fileInfo.mimeType) !== "text") return;128 fetch(fileContentUrl()).then((r) => r.text()).then(setTextContent).catch(() => setTextContent("Impossible de charger le contenu."));129 // eslint-disable-next-line react-hooks/exhaustive-deps130 }, [fileInfo, state]);131132 const handlePasswordSubmit = async (e: React.FormEvent) => {133 e.preventDefault();134 setPasswordLoading(true);135 setError("");136 try {137 const res = await fetch(`${base}/verify`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) });138 const data = await res.json().catch(() => ({}));139 if (!res.ok) { setError(data.error || "Mot de passe incorrect"); return; }140 // Le cookie signé est posé : on recharge normalement141 setState("loading");142 await load();143 } catch {144 setError("Erreur réseau");145 } finally {146 setPasswordLoading(false);147 }148 };149150 const openFolder = async (folderId: string) => {151 setFolderLoading(true);152 try {153 const res = await fetch(`${base}?folderId=${folderId}`, { cache: "no-store" });154 if (res.ok) {155 const data = await res.json();156 if (data.type === "folder") { setListing(data); setCurrentFolder(data.folder.id); }157 }158 } finally {159 setFolderLoading(false);160 }161 };162163 const handleDownload = () => window.open(fileContentUrl(true), "_blank");164165 if (state === "loading") {166 return (167 <Shell centered>168 <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4">169 <Logo size={56} />170 <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />171 <p className="text-sm text-gray-500">Chargement du partage…</p>172 </motion.div>173 </Shell>174 );175 }176177 if (state === "error") {178 return (179 <Shell centered>180 <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="card p-8 text-center max-w-sm w-full">181 <div className={`w-16 h-16 rounded-3xl mx-auto mb-5 flex items-center justify-center ${errorType === "expired" ? "bg-amber-500/10 text-amber-500" : "bg-rose-500/10 text-rose-500"}`}>182 {errorType === "expired" ? <Clock className="w-8 h-8" /> : <AlertCircle className="w-8 h-8" />}183 </div>184 <h1 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">185 {errorType === "expired" ? "Lien expiré" : errorType === "network" ? "Erreur de connexion" : "Lien invalide"}186 </h1>187 <p className="text-gray-500 dark:text-gray-400 text-sm">{error}</p>188 {errorType === "expired" && <p className="text-gray-400 text-xs mt-3">Demandez un nouveau lien au propriétaire.</p>}189 </motion.div>190 </Shell>191 );192 }193194 if (state === "password") {195 return (196 <Shell>197 <Header />198 <div className="flex-1 flex items-center justify-center p-4">199 <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="w-full max-w-sm">200 <div className="card p-6 sm:p-8">201 <div className="text-center mb-6">202 <div className="w-16 h-16 rounded-3xl bg-blue-500/10 text-blue-600 dark:text-blue-300 flex items-center justify-center mx-auto mb-4">203 <Lock className="w-8 h-8" />204 </div>205 <h1 className="text-lg font-semibold text-gray-900 dark:text-white">{protectedType === "folder" ? "Dossier protégé" : "Fichier protégé"}</h1>206 <p className="text-sm text-gray-500 mt-1 truncate max-w-[280px] mx-auto">{protectedName}</p>207 </div>208 <form onSubmit={handlePasswordSubmit} className="space-y-3">209 <input type="password" value={password} onChange={(e) => { setPassword(e.target.value); setError(""); }} placeholder="Mot de passe du partage" className="field h-12 text-center" autoFocus />210 {error && <p className="text-rose-500 text-sm text-center" role="alert">{error}</p>}211 <Button type="submit" variant="accent" size="lg" className="w-full" loading={passwordLoading} disabled={!password}>Accéder</Button>212 </form>213 </div>214 </motion.div>215 </div>216 <Footer expiresAt={null} />217 </Shell>218 );219 }220221 /* ================= DOSSIER PARTAGÉ ================= */222 if (state === "folder" && listing) {223 const isRoot = currentFolder === listing.id;224 const previewMode = listing.mode === "PREVIEW";225 return (226 <Shell>227 <Header228 title={listing.folder.name}229 subtitle={`${listing.files.length} fichier${listing.files.length !== 1 ? "s" : ""}${listing.folders.length ? ` · ${listing.folders.length} sous-dossier${listing.folders.length !== 1 ? "s" : ""}` : ""}`}230 isFolder231 onDownload={() => window.open(zipUrl(isRoot ? null : currentFolder), "_blank")}232 downloadLabel="Télécharger en ZIP"233 />234 <main className="flex-1 w-full max-w-5xl mx-auto px-3 sm:px-6 py-4 sm:py-6">235 {/* Fil d'Ariane borné à la racine du partage */}236 <nav className="flex items-center gap-1 text-sm mb-4 overflow-x-auto scrollbar-none whitespace-nowrap" aria-label="Fil d'Ariane">237 {listing.crumbs.map((c, i) => {238 const last = i === listing.crumbs.length - 1;239 return (240 <span key={c.id} className="flex items-center gap-1">241 {i > 0 && <ChevronRight className="w-3.5 h-3.5 text-gray-300 dark:text-gray-600" />}242 <button243 onClick={() => !last && openFolder(c.id)}244 className={`flex items-center gap-1.5 px-2 py-1 rounded-lg transition-colors ${last ? "font-semibold text-gray-900 dark:text-white" : "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/5"}`}245 disabled={last}246 >247 {i === 0 && <Home className="w-4 h-4" />}248 {c.name}249 </button>250 </span>251 );252 })}253 </nav>254255 <motion.div key={currentFolder} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className={`card overflow-hidden ${folderLoading ? "opacity-60" : ""}`}>256 {listing.folders.length === 0 && listing.files.length === 0 ? (257 <div className="py-16 text-center text-gray-500">258 <Folder className="w-10 h-10 mx-auto mb-3 text-gray-300 dark:text-gray-600" />259 <p className="text-sm">Ce dossier est vide.</p>260 </div>261 ) : (262 <ul className="divide-y divide-line dark:divide-white/[0.06]">263 {listing.folders.map((f) => (264 <li key={f.id}>265 <button onClick={() => openFolder(f.id)} className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/[0.04] transition-colors">266 <span className="w-9 h-9 rounded-lg bg-blue-500/10 flex items-center justify-center shrink-0"><Folder className="w-[18px] h-[18px] text-blue-500 fill-blue-500/20" /></span>267 <span className="flex-1 min-w-0">268 <span className="block text-[14px] font-medium text-gray-900 dark:text-white truncate">{f.name}</span>269 <span className="block text-[12px] text-gray-500">{f.files} fichier{f.files !== 1 ? "s" : ""}{f.children ? ` · ${f.children} sous-dossier${f.children !== 1 ? "s" : ""}` : ""}</span>270 </span>271 <a href={zipUrl(f.id)} onClick={(e) => e.stopPropagation()} className="hidden sm:inline-flex w-9 h-9 items-center justify-center rounded-lg text-gray-400 hover:text-gray-800 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10" title="Télécharger ce dossier en ZIP" aria-label="ZIP">272 <Download className="w-4 h-4" />273 </a>274 <ChevronRight className="w-4 h-4 text-gray-300 dark:text-gray-600" />275 </button>276 </li>277 ))}278 {listing.files.map((f) => {279 const cat = getFileCategory(f.mimeType);280 return (281 <li key={f.id} className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/[0.04] transition-colors">282 <span className="w-9 h-9 rounded-lg bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center shrink-0"><CategoryIcon category={cat} className="w-[18px] h-[18px]" /></span>283 <span className="flex-1 min-w-0">284 <span className="block text-[14px] text-gray-900 dark:text-white truncate">{f.name}</span>285 <span className="block text-[12px] text-gray-500 tabular-nums">{formatSize(f.size)}</span>286 </span>287 {previewMode && ["image", "pdf", "video", "audio", "text"].includes(cat) && (288 <a href={fileContentUrl(false, f.id)} target="_blank" rel="noopener" className="inline-flex w-9 h-9 items-center justify-center rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-500/10" title="Aperçu" aria-label="Aperçu">289 <Eye className="w-4 h-4" />290 </a>291 )}292 <a href={fileContentUrl(true, f.id)} className="inline-flex w-9 h-9 items-center justify-center rounded-lg text-gray-400 hover:text-gray-800 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10" title="Télécharger" aria-label="Télécharger">293 <Download className="w-4 h-4" />294 </a>295 </li>296 );297 })}298 </ul>299 )}300 </motion.div>301 </main>302 <Footer expiresAt={listing.expiresAt} />303 </Shell>304 );305 }306307 /* ================= FICHIER : téléchargement seul ================= */308 if (state === "download") {309 const category = fileInfo ? getFileCategory(fileInfo.mimeType) : "other";310 return (311 <Shell>312 <Header title={fileInfo?.name} subtitle={fileInfo ? formatSize(fileInfo.size) : undefined} onDownload={handleDownload} />313 <div className="flex-1 flex items-center justify-center p-4">314 <motion.div initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} className="card p-8 sm:p-10 text-center max-w-md w-full">315 <div className="w-24 h-24 rounded-3xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center mx-auto mb-6"><CategoryIcon category={category} /></div>316 <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1 break-words">{fileInfo?.name}</h2>317 <p className="text-sm text-gray-500 mb-6 tabular-nums">{fileInfo ? formatSize(fileInfo.size) : ""}</p>318 <Button onClick={handleDownload} variant="accent" size="lg" className="w-full sm:w-auto"><Download className="w-5 h-5" /> Télécharger</Button>319 </motion.div>320 </div>321 <Footer expiresAt={fileInfo?.expiresAt || null} />322 </Shell>323 );324 }325326 /* ================= FICHIER : aperçu ================= */327 const category = fileInfo ? getFileCategory(fileInfo.mimeType) : "other";328 return (329 <Shell>330 <Header title={fileInfo?.name} subtitle={fileInfo ? formatSize(fileInfo.size) : undefined} onDownload={handleDownload} />331 <main className="flex-1 flex items-center justify-center p-3 sm:p-6 overflow-auto">332 <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} className="w-full h-full flex items-center justify-center">333 {category === "image" && fileInfo && (334 <div className="relative flex flex-col items-center gap-3 max-w-full">335 <div className="overflow-auto max-h-[75vh] max-w-full rounded-2xl shadow-pop">336 <img src={fileContentUrl()} alt={fileInfo.name} className="max-w-full object-contain rounded-2xl transition-transform duration-200" style={{ transform: `scale(${imageZoom})`, transformOrigin: "center" }} />337 </div>338 <div className="flex items-center gap-1 glass rounded-full px-1.5 py-1">339 <button onClick={() => setImageZoom((z) => Math.max(0.25, z - 0.25))} className="w-9 h-9 rounded-full flex items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-white/10" aria-label="Réduire"><ZoomOut className="w-4 h-4" /></button>340 <span className="text-xs text-gray-600 dark:text-gray-300 min-w-[3rem] text-center tabular-nums">{Math.round(imageZoom * 100)}%</span>341 <button onClick={() => setImageZoom((z) => Math.min(4, z + 0.25))} className="w-9 h-9 rounded-full flex items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-white/10" aria-label="Agrandir"><ZoomIn className="w-4 h-4" /></button>342 </div>343 </div>344 )}345 {category === "pdf" && fileInfo && <iframe src={fileContentUrl()} className="w-full h-[80vh] border-0 rounded-2xl bg-white shadow-pop" title={fileInfo.name} />}346 {category === "video" && fileInfo && (347 <video controls className="max-w-full max-h-[75vh] rounded-2xl shadow-pop bg-black" preload="metadata">348 <source src={fileContentUrl()} type={fileInfo.mimeType} />349 Votre navigateur ne supporte pas la lecture vidéo.350 </video>351 )}352 {category === "audio" && fileInfo && (353 <div className="w-full max-w-lg">354 <div className="card p-8 text-center">355 <div className="w-20 h-20 rounded-3xl bg-pink-500/10 flex items-center justify-center mx-auto mb-6"><Music className="w-10 h-10 text-pink-500" /></div>356 <h3 className="text-gray-900 dark:text-white font-medium mb-4 truncate">{fileInfo.name}</h3>357 <audio controls className="w-full" preload="metadata"><source src={fileContentUrl()} type={fileInfo.mimeType} />Votre navigateur ne supporte pas la lecture audio.</audio>358 </div>359 </div>360 )}361 {category === "text" && (362 <div className="w-full max-w-4xl max-h-[75vh] overflow-auto">363 <div className="card p-4 sm:p-6">364 {textContent === null ? <div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 text-blue-500 animate-spin" /></div>365 : <pre className="text-[13px] text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words font-mono leading-relaxed">{textContent}</pre>}366 </div>367 </div>368 )}369 {(category === "other" || category === "spreadsheet" || category === "archive") && fileInfo && (370 <div className="card p-8 sm:p-10 text-center max-w-md w-full">371 <div className="w-24 h-24 rounded-3xl bg-gray-100 dark:bg-white/[0.06] flex items-center justify-center mx-auto mb-6"><CategoryIcon category={category} /></div>372 <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1 break-words">{fileInfo.name}</h2>373 <p className="text-sm text-gray-500 mb-2 tabular-nums">{formatSize(fileInfo.size)}</p>374 <p className="text-xs text-gray-400 mb-6">Aperçu non disponible pour ce type de fichier.</p>375 <Button onClick={handleDownload} variant="accent"><Download className="w-4 h-4" /> Télécharger</Button>376 </div>377 )}378 </motion.div>379 </main>380 <Footer expiresAt={fileInfo?.expiresAt || null} />381 </Shell>382 );383}384385// --- Sous-composants ---386387function Shell({ children, centered }: { children: React.ReactNode; centered?: boolean }) {388 return (389 <div className={`min-h-[100dvh] bg-canvas relative flex flex-col ${centered ? "items-center justify-center p-4" : ""}`}>390 <div className="absolute inset-0 pointer-events-none bg-aurora opacity-50 dark:opacity-35" />391 <div className="absolute inset-0 pointer-events-none bg-grid bg-[size:40px_40px] opacity-[0.25] dark:opacity-[0.12] [mask-image:radial-gradient(ellipse_at_top,black_20%,transparent_70%)]" />392 {centered && (393 <div className="absolute top-4 left-4 right-4 flex items-center justify-between">394 <Logo size={32} wordmark wordmarkClassName="text-[15px]" />395 <ThemeToggle />396 </div>397 )}398 <div className={`relative flex flex-col ${centered ? "w-full items-center" : "flex-1 min-h-[100dvh]"}`}>{children}</div>399 </div>400 );401}402403function Header({ title, subtitle, isFolder, onDownload, downloadLabel = "Télécharger" }: { title?: string; subtitle?: string; isFolder?: boolean; onDownload?: () => void; downloadLabel?: string }) {404 return (405 <header className="sticky top-0 z-10 glass border-x-0 border-t-0 rounded-none">406 <div className="max-w-6xl mx-auto flex items-center justify-between gap-3 px-4 h-16">407 <div className="flex items-center gap-3 min-w-0">408 <Logo size={34} wordmark={!title} wordmarkClassName="text-[15px]" />409 {title && (410 <div className="min-w-0 border-l border-line dark:border-white/10 pl-3 flex items-center gap-2">411 {isFolder && <Folder className="w-5 h-5 text-blue-500 fill-blue-500/20 shrink-0" />}412 <div className="min-w-0">413 <p className="text-[14px] text-gray-900 dark:text-white font-medium truncate max-w-[200px] sm:max-w-[420px]">{title}</p>414 {subtitle && <p className="text-[11.5px] text-gray-500 tabular-nums">{subtitle}</p>}415 </div>416 </div>417 )}418 </div>419 <div className="flex items-center gap-1.5">420 <ThemeToggle />421 {onDownload && (422 <Button size="sm" variant="accent" onClick={onDownload}>423 <Download className="w-4 h-4" />424 <span className="hidden sm:inline">{downloadLabel}</span>425 </Button>426 )}427 </div>428 </div>429 </header>430 );431}432433function Footer({ expiresAt }: { expiresAt: string | null }) {434 const expiryText = formatExpiry(expiresAt);435 return (436 <footer className="px-4 py-4 text-center pb-safe">437 <p className="text-[12px] text-gray-400 dark:text-gray-500 flex items-center justify-center gap-2 flex-wrap">438 <span>Propulsé par <span className="font-medium text-gray-600 dark:text-gray-300">SPB Cloud</span> · partage chiffré</span>439 {expiryText && (440 <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400">441 <Clock className="w-3 h-3" /> {expiryText}442 </span>443 )}444 </p>445 </footer>446 );447}448